/*
 * Copyright (C) 2006-2007 Sun Microsystems, Inc. All rights reserved. Use is
 * subject to license terms.
 */

/*
 * TO DO LIST: - Re-think use of PropertyResolutionException. Many of the cases
 * should be AssertionErrors, because they shouldn't happen. For the others, we
 * should either use an Error subclass to indicate they're unrecoverable, or we
 * need to try to leave the object in a consistent state. This is very difficult
 * in methods like updateCachedSources where an exception can occur at any time
 * while processing the chain. - Do testing with applets/security managers. -
 * Introspector/reflection doesn't work for non-public classes. EL handles this
 * by trying to find a version of the method in a public superclass/interface.
 * Looking at the code for Introspector (also used by EL), I got the idea that
 * it already does something like this. Investigate why EL handles this in an
 * extra step, and decide what we need to do in this class. - Add option to turn
 * on validation. For now it's hard-coded to be off.
 */

package org.gwt.beansbinding.core.client;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.util.IdentityHashMap;
import java.util.Map;

import org.gwt.beansbinding.core.client.ext.BeanAdapterFactory;
import org.gwt.beansbinding.core.client.util.HasPropertyChangeSupport;
import org.gwt.beansbinding.observablecollections.client.ObservableMap;
import org.gwt.beansbinding.observablecollections.client.ObservableMapListener;

/**
 * An implementation of {@code Property} that uses a simple dot-separated path
 * syntax to address Java Beans properties of source objects. For example, to
 * create a property representing the {@code firstName} property of an obect:
 * <p>
 * 
 * <pre><code>
 *    BeanProperty.create("firstName");
 *</code></pre>
 * 
 * <p>
 * Or to create a property representing the {@code firstName} property of an
 * object's {@code mother} property:
 * <p>
 * 
 * <pre><code>
 *    BeanProperty.create("mother.firstName");
 * </code></pre>
 * 
 * <p>
 * An instance of {@code BeanProperty} is immutable and can be used with
 * different source objects. When a {@code PropertyStateListener} is added to a
 * {@code BeanProperty} for a given source object, the {@code BeanProperty}
 * starts listening to all objects along the path (based on that source object)
 * for change notification, and reflects any changes by notifying the listener
 * associated with the property for that source object. So, in the second
 * example above, if a {@code PropertyStateListener} is added to the property
 * for an object {@code Duke}, the {@code PropertyStateListener} is notified
 * when either {@code Duke's} mother changes (if the new mother's name is
 * different), or {@code Duke's mother's firstName} changes.
 * <p>
 * It is very important that any bean properties addressed via a
 * {@code BeanProperty} follow the Java Beans specification, including firing
 * property change notification; otherwise, {@code BeanProperty} cannot respond
 * to change. As some beans outside of your control may not follow the Java
 * Beans specification, {@code BeanProperty} always checks the
 * {@link org.jdesktop.beansbinding.ext.BeanAdapterFactory} to see if a delegate
 * provider has been registered to provide a delegate bean to take the place of
 * an object for a given property. See the <a
 * href="ext/package-summary.html">ext package level</a> documentation for more
 * details.
 * <p>
 * When there are no {@code PropertyStateListeners} installed on a
 * {@code BeanProperty} for a given source, all {@code Property} methods act by
 * traversing the entire path from the source to the end point, thereby always
 * providing "live" information. On the contrary, when there are
 * {@code PropertyStateListeners} installed, the beans along the path (including
 * the final value) are cached, and only updated upon notification of change
 * from a bean. Again, this makes it very important that any bean property that
 * could change along the path fires property change notification.
 * <p>
 * <a name="READABILITY"><b>Readability</b></a> of a {@code BeanProperty} for
 * a given source is defined as follows: <i>A {@code BeanProperty} is readable
 * for a given source if and only if a) each bean in the path, starting with the
 * source, defines a Java Beans getter method for the the property to be read on
 * it AND b) each bean in the path, starting with the source and ending with the
 * bean on which we read the final property, is {@code non-null}. The final
 * value being {@code null} does not affect the readability.</i>
 * <p>
 * So, in the second example given earlier, the {@code BeanProperty} is readable
 * for (@code Duke} when all of the following are true: {@code Duke} defines a
 * Java Beans getter for {@code mother}, {@code Duke's mother} defines a Java
 * Beans getter for {@code firstName}, {@code Duke} is {@code non-null},
 * {@code Duke's mother} is {@code non-null}. The {@code BeanProperty} is
 * therefore unreadable when any of the following is true: {@code Duke} does not
 * define a Java Beans getter for {@code mother}, {@code Duke's mother} does
 * not define a Java Beans getter for {@code firstName}, {@code Duke} is
 * {@code null}, {@code Duke's mother} is {@code null}.
 * <p>
 * <a name="WRITEABILITY"><b>Writeability</b></a> of a {@code BeanProperty}
 * for a given source is defined as follows: <i>A {@code BeanProperty} is
 * writeable for a given source if and only if a) each bean in the path,
 * starting with the source and ending with the bean on which we set the final
 * property, defines a Java Beans getter method for the property to be read on
 * it AND b) the bean on which we set the final property defines a Java Beans
 * setter for the property to be set on it AND c) each bean in the path,
 * starting with the source and ending with the bean on which we set the final
 * property, is {@code non-null}. The final value being {@code null} does not
 * affect the writeability.</i>
 * <p>
 * So, in the second example given earlier, the {@code BeanProperty} is
 * writeable for {@code Duke} when all of the following are true: {@code Duke}
 * defines a Java Beans getter for {@code mother}, {@code Duke's mother}
 * defines a Java Beans setter for {@code firstName}, {@code Duke} is
 * {@code non-null}, {@code Duke's mother} is {@code non-null}. The
 * {@code BeanProperty} is therefore unreadable when any of the following is
 * true: {@code Duke} does not define a Java Beans getter for {@code mother},
 * {@code Duke's mother} does not define a Java Beans setter for
 * {@code firstName}, {@code Duke} is {@code null}, {@code Duke's mother} is
 * {@code null}.
 * <p>
 * In addition to working on Java Beans properties, any object in the path can
 * be an instance of {@code Map}. In this case, the {@code Map's get} method is
 * used with the property name as the getter, and the {@code Map's put} method
 * is used with the property name as the setter. {@code BeanProperty} can only
 * respond to changes in {@code Maps} if they are instances of
 * {@link org.jdesktop.observablecollections.ObservableMap}.
 * <p>
 * Some methods in this class document that they can throw
 * {@code PropertyResolutionException} if an exception occurs while trying to
 * resolve the path. The throwing of this exception represents an abnormal
 * condition and if listeners are installed for the given source object, leaves
 * the {@code BeanProperty} in an inconsistent state for that source object. A
 * {@code BeanProperty} should not be used again for that same source object
 * after such an exception without first removing all listeners associated with
 * the {@code BeanProperty} for that source object.
 * 
 * @param <S> the type of source object that this {@code BeanProperty} operates
 *          on
 * @param <V> the type of value that this {@code BeanProperty} represents
 * 
 * @author Shannon Hickey
 * @author Scott Violet
 * @author georgopoulos.georgios(at)gmail.com
 */
public final class BeanProperty<S, V> extends PropertyHelper<S, V> {

  private Property<S, ?> baseProperty;
  private final PropertyPath path;
  private IdentityHashMap<S, SourceEntry> map = new IdentityHashMap<S, SourceEntry>();
  private static final Object NOREAD = new Object();

  private final class SourceEntry implements PropertyChangeListener,
      ObservableMapListener, PropertyStateListener {

    private S source;
    private Object cachedBean;
    private Object[] cache;
    private Object cachedValue;
    private Object cachedWriter;
    private boolean ignoreChange;

    private SourceEntry(S source) {
      this.source = source;
      cache = new Object[path.length()];
      cache[0] = NOREAD;

      if (baseProperty != null) {
        baseProperty.addPropertyStateListener(source, this);
      }

      updateCachedBean();
      updateCachedSources(0);
      updateCachedValue();
      updateCachedWriter();
    }

    private void cleanup() {
      for (int i = 0; i < path.length(); i++) {
        unregisterListener(cache[i], path.get(i), this);
      }

      if (baseProperty != null) {
        baseProperty.removePropertyStateListener(source, this);
      }

      cachedBean = null;
      cache = null;
      cachedValue = null;
      cachedWriter = null;
    }

    private boolean cachedIsReadable() {
      return cachedValue != NOREAD;
    }

    private boolean cachedIsWriteable() {
      return cachedWriter != null;
    }

    private int getSourceIndex(Object object) {
      for (int i = 0; i < cache.length; i++) {
        if (cache[i] == object) {
          return i;
        }
      }

      if (object instanceof Map) {
        return -1;
      }

      for (int i = 0; i < cache.length; i++) {
        if (cache[i] != null) {
          Object adapter = getAdapter(cache[i], path.get(i));
          if (adapter == object) {
            return i;
          }
        }
      }

      return -1;
    }

    private void updateCachedBean() {
      cachedBean = getBeanFromSource(source);
    }

    private void updateCachedSources(int index) {
      boolean loggedYet = false;

      Object src;

      if (index == 0) {
        src = cachedBean;

        if (cache[0] != src) {
          unregisterListener(cache[0], path.get(0), this);

          cache[0] = src;

          if (src == null) {
            loggedYet = true;
            log("updateCachedSources()", "source is null");
          } else {
            registerListener(src, path.get(0), this);
          }
        }

        index++;
      }

      for (int i = index; i < path.length(); i++) {
        Object old = cache[i];
        src = getProperty(cache[i - 1], path.get(i - 1));

        if (src != old) {
          unregisterListener(old, path.get(i), this);

          cache[i] = src;

          if (src == null) {
            if (!loggedYet) {
              loggedYet = true;
              log("updateCachedSources()", "missing source");
            }
          } else if (src == NOREAD) {
            if (!loggedYet) {
              loggedYet = true;
              log("updateCachedSources()", "missing read method");
            }
          } else {
            registerListener(src, path.get(i), this);
          }
        }
      }
    }

    // -1 already used to mean validate all
    // 0... means something in the path changed
    private void validateCache(int ignore) {

      /* In the future, this debugging code can be enabled via a flag */

      /*
       * for (int i = 0; i < path.length() - 1; i++) { if (i == ignore - 1) {
       * continue; }
       * 
       * Object src = cache[i];
       * 
       * if (src == NOREAD) { return; }
       * 
       * Object next = getProperty(src, path.get(i));
       * 
       * if (!match(next, cache[i + 1])) { log("validateCache()", "concurrent
       * modification"); } }
       * 
       * if (path.length() != ignore) { Object next =
       * getProperty(cache[path.length() - 1], path.getLast()); if
       * (!match(cachedValue, next)) { log("validateCache()", "concurrent
       * modification"); }
       * 
       * Object src = cache[path.length() - 1]; Object writer; if (src == null ||
       * src == NOREAD) { writer = null; } else { writer =
       * getWriter(cache[path.length() - 1], path.getLast()); }
       * 
       * if (cachedWriter != writer && (cachedWriter == null ||
       * !cachedWriter.equals(writer))) { log("validateCache()", "concurrent
       * modification"); } }
       */
    }

    private void updateCachedWriter() {
      Object src = cache[path.length() - 1];
      if (src == null || src == NOREAD) {
        cachedWriter = null;
      } else {
        cachedWriter = getWriter(src, path.getLast());
        if (cachedWriter == null) {
          log("updateCachedWriter()", "missing write method");
        }
      }
    }

    private void updateCachedValue() {
      Object src = cache[path.length() - 1];
      if (src == null || src == NOREAD) {
        cachedValue = NOREAD;
      } else {
        cachedValue = getProperty(cache[path.length() - 1], path.getLast());
        if (cachedValue == NOREAD) {
          log("updateCachedValue()", "missing read method");
        }
      }
    }

    private void bindingPropertyChanged(PropertyStateEvent pse) {
      validateCache(0);
      Object oldValue = cachedValue;
      boolean wasWriteable = cachedIsWriteable();
      updateCachedBean();
      updateCachedSources(0);
      updateCachedValue();
      updateCachedWriter();
      notifyListeners(wasWriteable, oldValue, this);
    }

    private void cachedValueChanged(int index) {
      validateCache(index);

      boolean wasWriteable = cachedIsWriteable();
      Object oldValue = cachedValue;

      updateCachedSources(index);
      updateCachedValue();
      if (index != path.length()) {
        updateCachedWriter();
      }

      notifyListeners(wasWriteable, oldValue, this);
    }

    private void mapValueChanged(ObservableMap map, Object key) {
      if (ignoreChange) {
        return;
      }

      int index = getSourceIndex(map);

      if (index == -1) {
        throw new AssertionError();
      }

      if (key.equals(path.get(index))) {
        cachedValueChanged(index + 1);
      }
    }

    public void propertyStateChanged(PropertyStateEvent pe) {
      if (!pe.getValueChanged()) {
        return;
      }

      bindingPropertyChanged(pe);
    }

    private void propertyValueChanged(PropertyChangeEvent pce) {
      if (ignoreChange) {
        return;
      }

      int index = getSourceIndex(pce.getSource());

      if (index == -1) {
        throw new AssertionError();
      }

      String propertyName = pce.getPropertyName();
      if (propertyName == null || path.get(index).equals(propertyName)) {
        cachedValueChanged(index + 1);
      }
    }

    public void propertyChange(PropertyChangeEvent e) {
      propertyValueChanged(e);
    }

    public void mapKeyValueChanged(ObservableMap map, Object key,
        Object lastValue) {
      mapValueChanged(map, key);
    }

    public void mapKeyAdded(ObservableMap map, Object key) {
      mapValueChanged(map, key);
    }

    public void mapKeyRemoved(ObservableMap map, Object key, Object value) {
      mapValueChanged(map, key);
    }
  }

  /**
   * Creates an instance of {@code BeanProperty} for the given path.
   * 
   * @param path the path
   * @return an instance of {@code BeanProperty} for the given path
   * @throws IllegalArgumentException if the path is null, or contains no
   *           property names
   */
  public static final <S, V> BeanProperty<S, V> create(String path) {
    return new BeanProperty<S, V>(null, path);
  }

  /**
   * Creates an instance of {@code BeanProperty} for the given base property and
   * path. The path is relative to the value of the base property.
   * 
   * @param baseProperty the base property
   * @param path the path
   * @return an instance of {@code BeanProperty} for the given base property and
   *         path
   * @throws IllegalArgumentException if the path is null, or contains no
   *           property names
   */
  public static final <S, V> BeanProperty<S, V> create(
      Property<S, ?> baseProperty, String path) {
    return new BeanProperty<S, V>(baseProperty, path);
  }

  /**
   * @throws IllegalArgumentException for empty or {@code null} path.
   */
  private BeanProperty(Property<S, ?> baseProperty, String path) {
    this.path = PropertyPath.createPropertyPath(path);
    this.baseProperty = baseProperty;
  }

  private Object getLastSource(S source) {
    Object src = getBeanFromSource(source);

    if (src == null || src == NOREAD) {
      return src;
    }

    for (int i = 0; i < path.length() - 1; i++) {
      src = getProperty(src, path.get(i));
      if (src == null) {
        log("getLastSource()", "missing source");
        return null;
      }

      if (src == NOREAD) {
        log("getLastSource()", "missing read method");
        return NOREAD;
      }
    }

    return src;
  }

  /**
   * {@inheritDoc}
   * <p>
   * See the class level documentation for the definition of <a
   * href="#WRITEABILITY">writeability</a>.
   * 
   * @throws UnsupportedOperationException {@inheritDoc}
   * @throws PropertyResolutionException if an exception occurs while resolving
   *           the path
   * @see #setValue
   * @see #isWriteable
   */
  public Class<? extends V> getWriteType(S source) {
    SourceEntry entry = map.get(source);

    if (entry != null) {
      entry.validateCache(-1);

      if (entry.cachedWriter == null) {
        throw new UnsupportedOperationException("Unwriteable");
      }

      return (Class<? extends V>) getType(entry.cache[path.length() - 1],
          path.getLast());
    }

    return (Class<? extends V>) getType(getLastSource(source), path.getLast());
  }

  /**
   * {@inheritDoc}
   * <p>
   * See the class level documentation for the definition of <a
   * href="#READABILITY">readability</a>.
   * 
   * @throws UnsupportedOperationException {@inheritDoc}
   * @throws PropertyResolutionException if an exception occurs while resolving
   *           the path
   * @see #isReadable
   */
  public V getValue(S source) {
    SourceEntry entry = map.get(source);

    if (entry != null) {
      entry.validateCache(-1);

      if (entry.cachedValue == NOREAD) {
        throw new UnsupportedOperationException("Unreadable");
      }

      return (V) entry.cachedValue;
    }

    Object src = getLastSource(source);
    if (src == null || src == NOREAD) {
      throw new UnsupportedOperationException("Unreadable");
    }

    src = getProperty(src, path.getLast());
    if (src == NOREAD) {
      log("getValue()", "missing read method");
      throw new UnsupportedOperationException("Unreadable");
    }

    return (V) src;
  }

  /**
   * {@inheritDoc}
   * <p>
   * See the class level documentation for the definition of <a
   * href="#WRITEABILITY">writeability</a>.
   * 
   * @throws UnsupportedOperationException {@inheritDoc}
   * @throws PropertyResolutionException if an exception occurs while resolving
   *           the path
   * @see #isWriteable
   * @see #getWriteType
   */
  public void setValue(S source, V value) {
    SourceEntry entry = map.get(source);

    if (entry != null) {
      entry.validateCache(-1);

      if (entry.cachedWriter == null) {
        throw new UnsupportedOperationException("Unwritable");
      }

      try {
        entry.ignoreChange = true;
        write(entry.cachedWriter, entry.cache[path.length() - 1],
            path.getLast(), value);
      } finally {
        entry.ignoreChange = false;
      }

      Object oldValue = entry.cachedValue;
      entry.updateCachedValue();
      notifyListeners(entry.cachedIsWriteable(), oldValue, entry);
    } else {
      setProperty(getLastSource(source), path.getLast(), value);
    }
  }

  /**
   * {@inheritDoc}
   * <p>
   * See the class level documentation for the definition of <a
   * href="#READABILITY">readability</a>.
   * 
   * @throws UnsupportedOperationException {@inheritDoc}
   * @throws PropertyResolutionException if an exception occurs while resolving
   *           the path
   * @see #isWriteable
   */
  public boolean isReadable(S source) {
    SourceEntry entry = map.get(source);

    if (entry != null) {
      entry.validateCache(-1);
      return entry.cachedIsReadable();
    }

    Object src = getLastSource(source);
    if (src == null || src == NOREAD) {
      return false;
    }

    Object reader = getReader(src, path.getLast());
    if (reader == null) {
      log("isReadable()", "missing read method");
      return false;
    }

    return true;
  }

  /**
   * {@inheritDoc}
   * <p>
   * See the class level documentation for the definition of <a
   * href="#WRITEABILITY">writeability</a>.
   * 
   * @throws UnsupportedOperationException {@inheritDoc}
   * @throws PropertyResolutionException if an exception occurs while resolving
   *           the path
   * @see #isReadable
   */
  public boolean isWriteable(S source) {
    SourceEntry entry = map.get(source);

    if (entry != null) {
      entry.validateCache(-1);
      return entry.cachedIsWriteable();
    }

    Object src = getLastSource(source);
    if (src == null || src == NOREAD) {
      return false;
    }

    Object writer = getWriter(src, path.getLast());
    if (writer == null) {
      log("isWritable()", "missing write method");
      return false;
    }

    return true;
  }

  private Object getBeanFromSource(S source) {
    if (baseProperty == null) {
      if (source == null) {
        log("getBeanFromSource()", "source is null");
      }

      return source;
    }

    if (!baseProperty.isReadable(source)) {
      log("getBeanFromSource()", "unreadable source property");
      return NOREAD;
    }

    Object bean = baseProperty.getValue(source);
    if (bean == null) {
      log("getBeanFromSource()", "source property returned null");
      return null;
    }

    return bean;
  }

  protected final void listeningStarted(S source) {
    SourceEntry entry = map.get(source);
    if (entry == null) {
      entry = new SourceEntry(source);
      map.put(source, entry);
    }
  }

  protected final void listeningStopped(S source) {
    SourceEntry entry = map.remove(source);
    if (entry != null) {
      entry.cleanup();
    }
  }

  private static boolean didValueChange(Object oldValue, Object newValue) {
    return oldValue == null || newValue == null || !oldValue.equals(newValue);
  }

  private void notifyListeners(boolean wasWriteable, Object oldValue,
      SourceEntry entry) {
    PropertyStateListener[] listeners = getPropertyStateListeners(entry.source);

    if (listeners == null || listeners.length == 0) {
      return;
    }

    oldValue = toUNREADABLE(oldValue);
    Object newValue = toUNREADABLE(entry.cachedValue);
    boolean valueChanged = didValueChange(oldValue, newValue);
    boolean writeableChanged = (wasWriteable != entry.cachedIsWriteable());

    if (!valueChanged && !writeableChanged) {
      return;
    }

    PropertyStateEvent pse = new PropertyStateEvent(this, entry.source,
        valueChanged, oldValue, newValue, writeableChanged,
        entry.cachedIsWriteable());

    this.firePropertyStateChange(pse);
  }

  /**
   * Returns a string representation of the {@code BeanProperty}. This method
   * is intended to be used for debugging purposes only, and the content and
   * format of the returned string may vary between implementations. The
   * returned string may be empty but may not be {@code null}.
   * 
   * @return a string representation of this {@code BeanProperty}
   */
  public String toString() {
    return getClass().getName() + "[" + path + "]";
  }

  /**
   * @throws PropertyResolutionException
   */
  private static BeanInfo getBeanInfo(Object object) {
    assert object != null;

    try {
      // PENDING(shannonh) - not sure about the last flag
      return Introspector.getBeanInfo(object.getClass());
    } catch (IntrospectionException ie) {
      throw new PropertyResolutionException("Exception while introspecting "
          + object.getClass().getName(), ie);
    }
  }

  /**
   * @throws PropertyResolutionException
   */
  private static PropertyDescriptor getPropertyDescriptor(Object object,
      String string) {
    assert object != null;

    PropertyDescriptor[] pds = getBeanInfo(object).getPropertyDescriptors();
    if (pds == null) {
      return null;
    }

    for (PropertyDescriptor pd : pds) {
      if (/*
           * !(pd instanceof IndexedPropertyDescriptor) &&
           */pd.getName().equals(string)) {
        return pd;
      }
    }

    return null;
  }

  /**
   * @throws PropertyResolutionException
   */
  private static Object invokeMethod(Method method, Object object,
      Object... args) {
    Exception reason = null;

    try {
      return method.invoke(object, args);
    } catch (Exception ex) {
      reason = ex;
    }

    throw new PropertyResolutionException("Exception invoking method " + method
        + " on " + object, reason);
  }

  private Object getReader(Object object, String string) {
    assert object != null;

    if (object instanceof Map) {
      return object;
    }

    object = getAdapter(object, string);

    PropertyDescriptor pd = getPropertyDescriptor(object, string);
    Method readMethod = null;
    return pd == null ? null : pd.getReadMethod();
  }

  /**
   * @throws PropertyResolutionException
   */
  private Object read(Object reader, Object object, String string) {
    assert reader != null;

    if (reader instanceof Map) {
      assert reader == object;
      return ((Map) reader).get(string);
    }

    object = getAdapter(object, string);

    return invokeMethod((Method) reader, object);
  }

  /**
   * @throws PropertyResolutionException
   */
  private Object getProperty(Object object, String string) {
    if (object == null || object == NOREAD) {
      return NOREAD;
    }

    Object reader = getReader(object, string);
    if (reader == null) {
      return NOREAD;
    }

    return read(reader, object, string);
  }

  /**
   * @throws PropertyResolutionException
   */
  private Class<?> getType(Object object, String string) {
    if (object == null || object == NOREAD) {
      throw new UnsupportedOperationException("Unwritable");
    }

    if (object instanceof Map) {
      return Object.class;
    }

    object = getAdapter(object, string);

    PropertyDescriptor pd = getPropertyDescriptor(object, string);
    if (pd == null || pd.getWriteMethod() == null) {
      log("getType()", "missing write method");
      throw new UnsupportedOperationException("Unwritable");
    }

    return pd.getPropertyType();
  }

  private Object getWriter(Object object, String string) {
    assert object != null;

    if (object instanceof Map) {
      return object;
    }

    object = getAdapter(object, string);

    PropertyDescriptor pd = getPropertyDescriptor(object, string);
    Method writeMethod = null;
    return pd == null ? null : pd.getWriteMethod();
  }

  /**
   * @throws PropertyResolutionException
   */
  private void write(Object writer, Object object, String string, Object value) {
    assert writer != null;

    if (writer instanceof Map) {
      assert writer == object;
      ((Map) writer).put(string, value);
      return;
    }

    object = getAdapter(object, string);

    invokeMethod((Method) writer, object, value);
  }

  /**
   * @throws PropertyResolutionException
   * @throws IllegalStateException
   */
  private void setProperty(Object object, String string, Object value) {
    if (object == null || object == NOREAD) {
      throw new UnsupportedOperationException("Unwritable");
    }

    Object writer = getWriter(object, string);
    if (writer == null) {
      log("setProperty()", "missing write method");
      throw new UnsupportedOperationException("Unwritable");
    }

    write(writer, object, string, value);
  }

  private static Object toUNREADABLE(Object src) {
    return src == NOREAD ? PropertyStateEvent.UNREADABLE : src;
  }

  private void registerListener(Object object, String property,
      SourceEntry entry) {
    assert object != null;

    if (object != NOREAD) {
      if (object instanceof ObservableMap) {
        ((ObservableMap) object).addObservableMapListener(entry);
      } else if (!(object instanceof Map)) {
        object = getAdapter(object, property);
        addPropertyChangeListener(object, entry);
      }
    }
  }

  /**
   * @throws PropertyResolutionException
   */
  private void unregisterListener(Object object, String property,
      SourceEntry entry) {
    if (object != null && object != NOREAD) {
      if (object instanceof ObservableMap) {
        ((ObservableMap) object).removeObservableMapListener(entry);
      } else if (!(object instanceof Map)) {
        object = getAdapter(object, property);
        removePropertyChangeListener(object, entry);
      }
    }
  }

  /**
   * @throws PropertyResolutionException
   */
  private static void addPropertyChangeListener(Object object,
      PropertyChangeListener listener) {
    if (object instanceof HasPropertyChangeSupport) {
      ((HasPropertyChangeSupport) object).addPropertyChangeListener(listener);
    } else {
      throw new IllegalArgumentException("Can't add listener to '" + object
          + "'");
    }
  }

  /**
   * @throws PropertyResolutionException
   */
  private static void removePropertyChangeListener(Object object,
      PropertyChangeListener listener) {
    if (object instanceof HasPropertyChangeSupport) {
      ((HasPropertyChangeSupport) object).removePropertyChangeListener(listener);
    } else {
      throw new IllegalArgumentException("Can't remove listener from '"
          + object + "'");
    }
  }

  private static boolean wrapsLiteral(Object o) {
    assert o != null;

    return o instanceof String || o instanceof Byte || o instanceof Character
        || o instanceof Boolean || o instanceof Short || o instanceof Integer
        || o instanceof Long || o instanceof Float || o instanceof Double;
  }

  // need special match method because when using reflection
  // to get a primitive value, the value is always wrapped in
  // a new object
  private static boolean match(Object a, Object b) {
    if (a == b) {
      return true;
    }

    if (a == null) {
      return false;
    }

    if (wrapsLiteral(a)) {
      return a.equals(b);
    }

    return false;
  }

  private Object getAdapter(Object o, String property) {
    Object adapter = null;
    adapter = BeanAdapterFactory.getAdapter(o, property);
    return adapter == null ? o : adapter;
  }

  private static final boolean LOG = false;

  private static void log(String method, String message) {
    if (LOG) {
      System.err.println("LOG: " + method + ": " + message);
    }
  }

}
